我們昨天完成了利用 Supabase 的使用者登入註冊系統,坦白說這種第三方認證服務確實挺方便的,通常也都提供其他OAuth的整合服務,讓幾年前還不太好做的認證系統變得非常簡單方便,只是需要花一點點時間閱讀官方文件並做一些專案設置即可。當然,實際上我們目前的登入系統相當的簡陋,還有許多加強的空間,但現階段夠我們用了!
目前為止我們已經建立了practice_records
資料表,讓該資料表與user.id
連動並建立了登入驗證系統,下一步就是在資料庫中塞入真正的整合資料,讓使用者每一次練習都會確實留下紀錄,方便日後回顧之餘,也是我們未來主控台顯示資料的基礎,馬上就開始吧!
在 /api/interview/evaluate
API 中,安全地獲取當前登入的使用者 ID。practice_records
資料表。prompt.ts
讓 AI 面試官也能處理後續問答。在動手寫程式碼之前,我們先思考一個問題:「我們究竟該什麼時候寫入練習紀錄?」,你可能會覺得這問題有些奇怪,「阿不就 AI 回答完之後把 AI 的回饋與使用者的回答寫入資料庫就好了嗎?」。這樣的回答對也不對,主要是因為要考慮我們目前的資料結構設計,想像一下真實的面試場景:對話並不是「一問一答」就結束了。使用者在收到 AI 的評估後,很可能會追問:「可以再解釋詳細一點嗎?」或「還有其他解法嗎?」。
如果我們的系統不區分這些互動,可能會把使用者的「追問」也當成一次新的「回答」存入資料庫,這顯然是錯誤的。因此,我們必須從一開始就設計一個能區分**「需要被記錄的首次回答」和「不需記錄的後續追問」**的機制。
最簡單有效的方法,就是在前端維護一個狀態,並在呼叫 API 時傳遞一個標記。
我們在前端頁面加入一個 isEvaluated
狀態。一旦首次評估成功返回,我們就將其標記為 true
,代表這次面試的核心評估環節已結束,後續的對話再出新的問題之前都屬於使用者追問的過程,不列入練習紀錄, AI 也不需要給回饋。
// app/interview/[sessionId]/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
// ... 其他 imports
export default function InterviewPage() {
// ... 其他狀態
const [isEvaluated, setIsEvaluated] = useState(false); // <-- 新增狀態
const handleSubmit = async () => {
// ...
try {
// ...
const response = await fetch('/api/interview/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// ... 其他 body 內容
isFollowUp: isEvaluated, // <-- 關鍵新增:將狀態傳給後端
}),
signal: abortControllerRef.current?.signal,
});
// ... 處理 response ...
while (true) {
const { done, value } = await reader.read();
if (done) {
try {
// ... 解析 finalJson ...
setIsEvaluated(true); // <-- 關鍵新增:首次評估成功後,更新狀態
} catch (e) {
// ... 錯誤處理 ...
}
break;
}
// ... 處理 streaming ...
}
} catch (error) {
// ... 錯誤處理 ...
}
};
// ... 剩下的元件 JSX
}
/api/interview/evaluate/route.ts
)有了前端的 isFollowUp
標記,我們的後端 API 現在就能夠像一個聰明的守衛,決定是否要執行「寫入資料庫」這個重要操作。
不過由於我們在之前的重構中就將路由的邏輯整理得很乾淨了,目前/api/interview/evaluate/route.ts
與 Stream 相關的邏輯只剩這一行。
const stream = await generateContentStream(finalPrompt);
但這可就有點麻煩了,我們的「寫入資料庫」邏輯,需要等到 Gemini 的串流完全結束後,拿到完整的 JSON 才能執行。但現在,整個串流過程被封裝在一個通用的 gemini.ts
函式裡,這個通用函式本身不應該、也不知道任何關於「寫入資料庫」的事情。
我們該如何解決這個問題?我們不能把 user
, questionId
等資訊全都傳入 generateContentStream
,那會破壞它的通用性,這些本身也與該函數無關,硬是塞進去只會讓這個重構後的函數變得混亂。
要解決的方法有很多種,我這邊選擇用 callback 函數的概念來處理,也就是讓我們的generateContentStream
接收一個新的函數做為參數,在該 callback 函數中我們有辦法傳入isFollowUp
這個值,這麼一來我們就可以順利的在generateContentStream
中達到我們預期的行為了。
首先請你打開app/lib/gemini.ts
檔案,我們先來修改generateContentStream
函數,你可以完整貼上以下的內容覆蓋原本的函數:
// app/lib/gemini.ts
/**
* 使用 Gemini 生成串流回應
* @param prompt 完整的 prompt 文字
* @param onComplete 串流結束後的回呼函式,用於處理完整的 JSON 回應
* @returns ReadableStream 用於串流回應
*/
export async function generateContentStream(
prompt: string,
onComplete?: (json: string) => void
): Promise<ReadableStream> {
const contents: Content[] = [{ parts: [{ text: prompt }] }];
const result = await genAI.models.generateContentStream({
model: 'gemini-2.5-flash',
contents: contents,
config: {
responseMimeType: 'application/json',
},
});
return new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let accumulatedJson = '';
for await (const chunk of result) {
const text = chunk.text;
if (text) {
controller.enqueue(encoder.encode(text));
accumulatedJson += text;
}
}
// 【核心修改】串流結束後,檢查並執行 onComplete 回呼
if (onComplete) {
try {
onComplete(accumulatedJson);
} catch (e) {
// 在伺服器端紀錄回呼函式執行時的錯誤
console.error('Error executing onComplete callback:', e);
}
}
controller.close();
},
});
}
接著請打開 app/api/interview/evaluate/route.ts
,我們將整合身分驗證和這個新的判斷邏輯。
// app/api/interview/evaluate/route.ts
import {
createAuthClient,
supabase as adminSupabase,
} from '@/app/lib/supabase/server';
// ... 其他 imports
export async function POST(request: Request) {
// 1. 驗證使用者身分
const supabase = createServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// 2. 取得 isFollowUp 旗標
const { questionId, answer, history, isFollowUp } = await request.json();
// ... RAG, Judge0, Gemini prompt 準備 ...
// 3. 執行串流與條件式寫入
const stream = await generateContentStream(
finalPrompt,
async (fullJson) => {
// 這個函式會在 gemini.ts 中被呼叫
// 只有在不是追問的情況下,才執行資料庫寫入
if (!isFollowUp) {
try {
const finalEvaluation = JSON.parse(fullJson);
const recordToInsert = {
user_id: user.id,
question_id: questionId,
user_answer: answer,
evaluation: finalEvaluation,
score: finalEvaluation.score,
};
const { error: insertError } = await adminSupabase
.from('practice_records')
.insert(recordToInsert);
if (insertError) {
console.error('Error in onComplete DB write:', insertError);
}
} catch (e) {
console.error('Failed to parse or insert record in onComplete:', e);
}
}
}
);
return new Response(stream, { headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}
createAuthClient
以及有修改資料庫權限的supabase實體。isFolowUp
flag讓我們能做後續的判斷。generateContentStream
函數,其中我們在串流結束後,用 if (!isFollowUp)
作為一個「閘門」,只有首次提交(isFollowUp 為 false)的請求才能通過並寫入資料庫,任何後續的對話都會被這個閘門擋下,從而完美地保護了資料的正確性。現在試著開啟開發伺服器
npm run dev
隨便用概念問答或是程式實作題目去做一個問題的回答,接著打開你 Supabase 的儀表板,你應該會看到確實有資料被寫入,我們的改動很順利的完成了!
![]() |
---|
圖1 :資料成功寫入 |
接著再隨便輸入一個訊息給 AI 並再次檢查資料庫,你會發現資料並沒有再度新增一筆,我們的isFollowUp
邏輯也確實有用,但...有一點點不太對勁,如果你照著以上的操作你會發現個有趣的畫面,如下圖:
![]() |
---|
圖2 :非預期地追問畫面 |
明明我只是想與 AI 繼續討論,但他卻當作我還在回答問題,並給予了我評分,這很明顯不是我們想要的行為,最主要的原因還是在於我們當初在設計prompt.ts
時還是以單次問答設計,並沒有考慮到後續使用者可能有其他問題想問或是討論,為了處理這樣的情況,我們需要對我們的system prompt和後端服務做一點加工。
prompt.ts
請將以下的程式碼完整貼上到我們之前的prompt.ts
檔案:
// 保持模板的獨立性,使其易於管理和修改
export const unifiedPromptTemplate = `<role>
You are a world-class senior frontend technical interviewer. Your tone should be professional, insightful, and supportive. Address the candidate directly as "你".
</role>
<task>
Your primary task is to determine if this is an initial evaluation or a follow-up conversation based on the <is_follow_up> flag.
---
**CASE 1: This is a FOLLOW-UP CONVERSATION (<is_follow_up> is true)**
- Your role is to continue the conversation naturally based on the <conversation_history>.
- Answer the candidate's follow-up question, clarify points, or provide further examples.
- Your response MUST be a single, valid JSON object following the <json_schema>.
- In your response:
- Place your conversational reply into the \`summary\` field.
- You MUST set \`score\`, \`grounded_evidence\`, \`pros\`, and \`cons\` to \`null\`.
- You can optionally provide suggestions in the \`next_practice\` field.
---
**CASE 2: This is the INITIAL EVALUATION (<is_follow_up> is false)**
- Your role is to provide a comprehensive evaluation of the candidate's answer (<candidate_answer>).
- Your evaluation must be grounded in the evidence given.
- Then, determine the question type:
- **If the question is conceptual**: Base your evaluation on <rag_context>. \`grounded_evidence\` MUST be \`null\`.
- **If the question is a coding challenge**: Base your evaluation on <judge0_result>. \`grounded_evidence\` MUST be populated.
- Your response MUST be a single, valid JSON object following the <json_schema>.
---
Always answer in Traditional Chinese.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5) | null",
"grounded_evidence": { "tests_passed": "number|null", "tests_failed": "number|null", "stderr_excerpt": "string|null" } | null,
"pros": "string[] | null",
"cons": "string[] | null",
"next_practice": "string[]"
}
</json_schema>
<is_follow_up>
\${isFollowUp}
</is_follow_up>
<conversation_history>
\${formattedHistory}
</conversation_history>
<question>
\${question}
</question>
<rag_context>
\${ragContext}
</rag_context>
<judge0_result>
\${judge0Result}
</judge0_result>
<candidate_answer>
\${userAnswer}
</candidate_answer>`;
// 【修改版】PromptContext 介面,增加 isFollowUp
interface PromptContext {
isFollowUp: boolean;
formattedHistory: string;
question: string;
ragContext: string;
judge0Result: string;
userAnswer: string;
}
/**
* 根據上下文填充統一的 Prompt 模板。
* @param context 包含所有需要填充的資訊的物件
* @returns 填充完畢的最終 Prompt 字串
*/
// 【修改版】buildUnifiedPrompt 函式,替換 isFollowUp 並修正 placeholder 名稱
export function buildUnifiedPrompt(context: PromptContext): string {
return unifiedPromptTemplate
.replace(/\${isFollowUp}/g, String(context.isFollowUp))
.replace(/\${formattedHistory}/g, context.formattedHistory)
.replace(/\${question}/g, context.question)
.replace(/\${ragContext}/g, context.ragContext)
.replace(/\${judge0Result}/g, context.judge0Result)
.replace(/\${userAnswer}/g, context.userAnswer);
}
我們加上isFollowUp
的判斷邏輯,若視後續的追問,則除了訊息之外的內容都不需要回傳,其餘的邏輯則保持不變。
buildUnifiedPrompt
傳入這個變數下一步則是回到我們的evaluate
api,在呼叫buildUnifiedPrompt
函數的地方加入我們剛剛的變數,isFollowUp
我們已經在前面的步驟中從前端的回應中取得了
const finalPrompt = buildUnifiedPrompt({
isFollowUp, // 加入這個
formattedHistory,
question: question.question,
ragContext,
judge0Result: judge0ResultText,
userAnswer: answer,
});
再次回到app/interview/[sessionId]/page.tsx
檔案,在串流結束的設置訊息的部分加一個邏輯。
setChatHistory((prevHistory) => {
const newHistory = [...prevHistory];
newHistory[newHistory.length - 1] = {
role: 'ai',
content: finalJson.summary || accumulatedResponse,
evaluation: isEvaluated ? null : finalJson, // 如果已經評估過,就不再傳evaluation去渲染
};
這些都完成後再次追問, AI 面試官就可正常回應你追問的問題囉!
今天,我們讓 AI 面試官真正擁有了「記憶」使用者的能力,為後續的個人化功能打下了最堅實的基礎。
✅ 實現了身分綁定:我們成功在核心 API 中獲取了登入使用者的 ID。
✅ 設計了穩健架構:透過 isFollowUp
旗標,我們從一開始就讓系統能區分評估與對話,避免了髒數據。
✅ 完成了資料持久化:使用者的每一次練習評估,現在都會被安全、準確地記錄在 practice_records
資料表中。
✅ 修改了prompt.ts
和前端渲染的邏輯,讓應用程式能正確地處理追問。
資料已經開始源源不絕地寫入資料庫了。下一步,當然就是要把這些數據以有意義的方式展示給使用者看!明天 (Day 24),我們將打造一個個人化的練習詳情頁面,讓使用者可以回顧每一次面試的完整細節。
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-23